iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Software Development

Roblox Studio AI 協作開發大全系列 第 15

第 15 章:敵人與簡單 AI

  • 分享至 

  • xImage
  •  

第 14 章完成了第一條任務線:玩家和 Guide 對話,蒐集水晶,回報任務,取得 Gold。現在島上已經有目標,但還沒有風險。冒險島如果只有收集物和 NPC,很快會變成散步模擬器;玩家需要一個會改變行動策略的阻力。

本章要加入第一個敵人。它不需要很聰明,只要能巡邏、偵測玩家、追蹤玩家,並在靠近時造成傷害,就已經能讓關卡變得有張力。

這章也會建立一個很重要的觀念:敵人 AI 和傷害判斷屬於 server。client 可以顯示畫面、播放音效、做提示,但不能告訴 server「敵人打到我了」或「敵人應該移動到這裡」。

本章目標

完成本章後,你會得到:

  • 一個放在島上的敵人模型。
  • 三個巡邏點。
  • 一個 server-side EnemyAIService
  • 敵人的 Patrol -> Chase -> ReturnToPatrol 狀態流程。
  • 玩家靠近時敵人追蹤玩家。
  • 敵人靠近玩家時造成傷害。
  • 傷害有 cooldown,不會瞬間連續扣血。
  • 一套用 Studio Client / Server 切換檢查敵人 AI 的測試方法。

Pathfinding 計算適合角色移動的路徑。

圖 15-1 敵人 AI 需要依導航網格尋路;最短直線不一定是角色實際可走且安全的路徑。

Context Pack

本章使用下列 Roblox 官方文件作為 context:

本章採用這些文件中的幾個原則:

  • HumanoidModel 具有角色功能,敵人模型要能移動,至少需要 HumanoidHumanoidRootPart
  • PathfindingService 可以計算兩點之間的路徑,但 path 可能失敗,不應假設每次都有路。
  • server-side detection 的核心是 server decides;敵人偵測、追蹤與傷害都在 server。
  • Studio testing modes 可以切換 Client / Server,檢查 server-only service 是否真的在 server 執行。

Playtest 執行期間的測試選項與 Client、Server 切換控制。

圖 15-2 測試敵人 AI 時切到 Server 檢查權威 Script 與狀態,再切回 Client 確認玩家實際看到的移動和傷害回饋。

  • debounce 可以防止傷害短時間觸發太多次。

開場情境

Guide 任務完成後,玩家拿到了第一筆 Gold。下一個設計目標是讓玩家穿過一小段危險區域,前往島的另一側。這個危險區域先不做 Boss,也不做複雜攻擊,只放一個巡邏敵人。

敵人的行為規則很簡單:

沒有玩家靠近:在巡邏點之間移動。
玩家進入 35 studs:轉向追蹤最近玩家。
玩家離開 45 studs:停止追蹤,回到巡邏。
敵人距離玩家 5 studs 以內:每 1.5 秒扣 15 HP。

這裡刻意使用兩個距離:35 和 45。偵測距離比脫離距離短,可以避免玩家站在邊界附近時,敵人在追蹤與不追蹤之間快速閃爍。

本章敵人 AI 使用三個狀態:

Patrol -> Chase -> ReturnToPatrol

這不是完整 AI 架構,但已經足夠建立第一個可玩的敵人流程。

第一個 Prompt

請 Assistant 建立第一版敵人 AI。

【Ch15 主任務|建立敵人 AI】
我們要繼續開發 Roblox Studio 專案「AI Adventure Island」。

現有背景:
- Workspace.AdventureIsland 已經存在。
- 玩家可以蒐集水晶並完成 Guide 任務。
- ServerScriptService 已經包含 QuestService 等 server-side service。
- 本章不要加入 DataStore、掉落物、XP 或戰鬥 UI。

請建立第一版簡單敵人 AI prototype:
1. 如果 Workspace.AdventureIsland.Enemies 不存在,請建立它。
2. 在 Enemies 底下建立一個名為 IslandSentinel_01 的敵人 Model。
3. 敵人 Model 必須包含 Humanoid 與 HumanoidRootPart,才能像角色一樣移動。
4. 建立 Workspace.AdventureIsland.PatrolPoints,並在其中加入三個 Anchored 且不可見的標記 Part:
   - SentinelPatrol_01_A
   - SentinelPatrol_01_B
   - SentinelPatrol_01_C
5. 建立 ServerScriptService.EnemyAIService。
6. EnemyAIService 只能在 server 執行。
7. 敵人使用以下狀態:
   - Patrol
   - Chase
   - ReturnToPatrol
8. 在 Patrol 狀態下,讓敵人在三個巡邏點之間移動。
9. 比較敵人 HumanoidRootPart 與每位玩家 HumanoidRootPart 的距離,找出最近的玩家。
10. 玩家進入 35 studs 內時,切換成 Chase。
11. 被追蹤的玩家距離超過 45 studs,或不再有有效角色時,切換回 Patrol。
12. 在 Chase 狀態下,朝玩家的 HumanoidRootPart 移動。
13. 敵人距離玩家 5 studs 以內時,對玩家的 Humanoid 造成 15 點傷害。
14. 為每位玩家分別加入 1.5 秒的傷害 cooldown。
15. 只有在有幫助時才使用 PathfindingService;如果路徑計算失敗,必須改用 Humanoid:MoveTo。

重要限制:
- 不要讓 client 決定敵人的移動或傷害。
- 不要為傷害建立 RemoteEvent。
- 不要建立完整的 behavior tree。
- 請加入 print 訊息,在 server Output 顯示狀態變化與傷害。

這個 prompt 的目的不是要求 Assistant 做出很厲害的敵人,而是要求它生成一個可測試的 server-side prototype。

特別注意這三句:

不要讓 client 決定敵人的移動或傷害。
不要為傷害建立 RemoteEvent。
為每位玩家分別加入 1.5 秒的傷害 cooldown。

這三句能避免三個常見錯誤:

  • 把敵人 AI 寫在 LocalScript
  • 讓 client 發 RemoteEvent 請求扣血。
  • 碰到一次就因為連續觸發而扣很多次血。

Assistant 可能產生的結果

Assistant 可能會建立這樣的結構:

Workspace
└── AdventureIsland
    ├── Enemies
    │   └── IslandSentinel_01
    │       ├── Humanoid
    │       ├── HumanoidRootPart
    │       └── Head 或其他外觀 Part
    └── PatrolPoints
        ├── SentinelPatrol_01_A
        ├── SentinelPatrol_01_B
        └── SentinelPatrol_01_C

ServerScriptService
└── EnemyAIService

EnemyAIService 可能會包含這些常數:

local DETECTION_DISTANCE = 35
local LOSE_DISTANCE = 45
local ATTACK_DISTANCE = 5
local DAMAGE_AMOUNT = 15
local DAMAGE_COOLDOWN = 1.5

先讓數值集中在檔案上方,之後調整難度會比較容易。不要把 35、45、5、15 散落在程式各處。

第一版核心邏輯可能像這樣:

local Players = game:GetService("Players")
local PathfindingService = game:GetService("PathfindingService")
local Workspace = game:GetService("Workspace")

local island = Workspace:WaitForChild("AdventureIsland")
local enemies = island:WaitForChild("Enemies")
local patrolPointsFolder = island:WaitForChild("PatrolPoints")

local enemy = enemies:WaitForChild("IslandSentinel_01")
local enemyHumanoid = enemy:WaitForChild("Humanoid")
local enemyRoot = enemy:WaitForChild("HumanoidRootPart")

local patrolPoints = {
	patrolPointsFolder:WaitForChild("SentinelPatrol_01_A"),
	patrolPointsFolder:WaitForChild("SentinelPatrol_01_B"),
	patrolPointsFolder:WaitForChild("SentinelPatrol_01_C"),
}

local DETECTION_DISTANCE = 35
local LOSE_DISTANCE = 45
local ATTACK_DISTANCE = 5
local DAMAGE_AMOUNT = 15
local DAMAGE_COOLDOWN = 1.5

local state = "Patrol"
local patrolIndex = 1
local currentTargetPlayer = nil
local lastDamageTimeByPlayer = {}

local function setState(nextState)
	if state == nextState then
		return
	end

	print("IslandSentinel_01 state:", state, "->", nextState)
	state = nextState
end

local function getCharacterRoot(player)
	local character = player.Character
	if not character then
		return nil
	end

	return character:FindFirstChild("HumanoidRootPart")
end

local function getCharacterHumanoid(player)
	local character = player.Character
	if not character then
		return nil
	end

	return character:FindFirstChildWhichIsA("Humanoid")
end

local function findNearestPlayer(maxDistance)
	local nearestPlayer = nil
	local nearestDistance = maxDistance

	for _, player in Players:GetPlayers() do
		local playerRoot = getCharacterRoot(player)
		local playerHumanoid = getCharacterHumanoid(player)

		if playerRoot and playerHumanoid and playerHumanoid.Health > 0 then
			local distance = (playerRoot.Position - enemyRoot.Position).Magnitude
			if distance <= nearestDistance then
				nearestDistance = distance
				nearestPlayer = player
			end
		end
	end

	return nearestPlayer, nearestDistance
end

local function damagePlayerIfClose(player)
	local playerRoot = getCharacterRoot(player)
	local playerHumanoid = getCharacterHumanoid(player)
	if not playerRoot or not playerHumanoid then
		return
	end

	local distance = (playerRoot.Position - enemyRoot.Position).Magnitude
	if distance > ATTACK_DISTANCE then
		return
	end

	local now = os.clock()
	local lastDamageTime = lastDamageTimeByPlayer[player] or 0
	if now - lastDamageTime < DAMAGE_COOLDOWN then
		return
	end

	lastDamageTimeByPlayer[player] = now
	playerHumanoid:TakeDamage(DAMAGE_AMOUNT)
	print("IslandSentinel_01 damaged", player.Name, "for", DAMAGE_AMOUNT)
end

這段還沒有移動,只先把「找最近玩家」和「傷害 cooldown」拆出來。這樣寫的好處是:如果敵人不會動,你可以先測偵測;如果敵人會追但不扣血,你可以單獨看 damagePlayerIfClose()

移動可以先用簡化版:

local function moveToPosition(position)
	enemyHumanoid:MoveTo(position)
end

local function patrolStep()
	local point = patrolPoints[patrolIndex]
	moveToPosition(point.Position)

	local nearestPlayer = findNearestPlayer(DETECTION_DISTANCE)
	if nearestPlayer then
		currentTargetPlayer = nearestPlayer
		setState("Chase")
		return
	end

	if (enemyRoot.Position - point.Position).Magnitude < 4 then
		patrolIndex += 1
		if patrolIndex > #patrolPoints then
			patrolIndex = 1
		end
	end
end

local function chaseStep()
	if not currentTargetPlayer then
		setState("ReturnToPatrol")
		return
	end

	local playerRoot = getCharacterRoot(currentTargetPlayer)
	local playerHumanoid = getCharacterHumanoid(currentTargetPlayer)
	if not playerRoot or not playerHumanoid or playerHumanoid.Health <= 0 then
		currentTargetPlayer = nil
		setState("ReturnToPatrol")
		return
	end

	local distance = (playerRoot.Position - enemyRoot.Position).Magnitude
	if distance > LOSE_DISTANCE then
		currentTargetPlayer = nil
		setState("ReturnToPatrol")
		return
	end

	moveToPosition(playerRoot.Position)
	damagePlayerIfClose(currentTargetPlayer)
end

while enemyHumanoid.Health > 0 do
	if state == "Patrol" then
		patrolStep()
	elseif state == "Chase" then
		chaseStep()
	elseif state == "ReturnToPatrol" then
		setState("Patrol")
	end

	task.wait(0.25)
end

這個版本還沒有真正用 pathfinding。它先用 Humanoid:MoveTo() 建立最小可用行為。這是可以接受的,因為本章要先讓讀者理解 AI 狀態與 server-side damage。

如果關卡上有障礙物,Assistant 可能會加入 PathfindingService

local function moveWithPath(targetPosition)
	local path = PathfindingService:CreatePath({
		AgentRadius = 2,
		AgentHeight = 5,
		AgentCanJump = true,
	})

	local success = pcall(function()
		path:ComputeAsync(enemyRoot.Position, targetPosition)
	end)

	if success and path.Status == Enum.PathStatus.Success then
		for _, waypoint in path:GetWaypoints() do
			enemyHumanoid:MoveTo(waypoint.Position)
			enemyHumanoid.MoveToFinished:Wait()
		end
	else
		enemyHumanoid:MoveTo(targetPosition)
	end
end

這裡要小心:不要每 0.25 秒完整計算一次長 path,否則敵人多了會很浪費。第一版可以只在巡邏點移動或追蹤目標變化時重新計算。若只是空曠小島,先用 MoveTo() 反而比較清楚。

元件拆解

Humanoid

Humanoid 是 Roblox 角色模型的核心物件。玩家角色有 Humanoid,NPC 敵人也可以有 Humanoid。

對本章來說,Humanoid 提供三件事:

  • MoveTo():讓敵人往某個位置移動。
  • Health:敵人或玩家的生命值。
  • TakeDamage():用一致的方式扣血。

敵人模型至少要有:

IslandSentinel_01
├── Humanoid
└── HumanoidRootPart

如果 Assistant 生成的是一堆普通 Part,但沒有 HumanoidHumanoidRootPart,那只是雕像,不是能移動的敵人。

HumanoidRootPart

HumanoidRootPart 是判斷距離與移動定位時最常使用的基準點。

本章用它做兩件事:

local distance = (playerRoot.Position - enemyRoot.Position).Magnitude

以及:

enemyHumanoid:MoveTo(playerRoot.Position)

這比拿 Head 或某個外觀 Part 當基準穩定。外觀 Part 可能改名、被替換或不在模型中心;RootPart 則是角色移動的核心。

PatrolPoints

巡邏點不是玩家會看到的美術,而是設計師用來控制 AI 路線的 marker。

建議讓 Assistant 把巡邏點設為:

Anchored = true
CanCollide = false
Transparency = 1

這樣它們不會擋住玩家,也不會出現在畫面中,但在 Explorer 裡仍然可以被選取與移動。

PathfindingService

PathfindingService 適合敵人需要繞過障礙物時使用。它可以根據 agent 的半徑、高度、能否跳躍等設定建立路徑。

本章要傳達的是:pathfinding 不是魔法。它可能失敗,也可能算出不符合你設計期望的路線,所以程式要檢查:

if success and path.Status == Enum.PathStatus.Success then
	-- follow waypoints
else
	-- fallback
end

在小型 prototype 中,先用 Humanoid:MoveTo() 可以減少干擾;當你發現敵人被石頭、橋或高度差卡住,再讓 Assistant 協助加上 pathfinding。

Server-side Detection

敵人 AI 不應該靠 client 告訴 server 發生了什麼。

不好的設計:

Client detects that enemy touched player -> FireServer("DamageMe")

比較好的設計:

Server checks enemy position and player position -> Server calls Humanoid:TakeDamage()

本章敵人距離判斷都在 EnemyAIService 中執行。client 看到的是角色被扣血後同步來的結果,而不是由 client 決定傷害。

Debounce

傷害必須有 cooldown。

如果沒有 cooldown,敵人每次 loop 都可能扣血:

0.00 秒:扣 15
0.25 秒:扣 15
0.50 秒:扣 15
0.75 秒:扣 15

這不是難度高,而是 bug。

本章使用 per-player cooldown:

local lastDamageTimeByPlayer = {}

每個玩家各自記錄最後受傷時間。這樣多人測試時,敵人攻擊 Player A 不會讓 Player B 也進入 cooldown。

Playtest

本章要用三種方式測試。

第一輪:單人 Play

  1. 按 Play。
  2. 在 Client 視角走近敵人巡邏區。
  3. 確認敵人從巡邏轉成追蹤。
  4. 讓敵人靠近自己。
  5. 確認玩家生命值下降,但不是每 0.25 秒連續扣血。
  6. 離開敵人追蹤距離。
  7. 確認敵人回到巡邏。

第二輪:切換 Server

  1. Play 中切換到 Server。
  2. 在 Explorer 找到 ServerScriptService.EnemyAIService
  3. 確認 server Output 有狀態切換 print。
  4. 檢查敵人模型位置是否在 server 端改變。

這一步是為了確認 AI 不是 client-only 假象。

第三輪:Server & Clients

  1. 使用 Studio 的 Server & Clients 模式,開 2 個 client。
  2. Client 1 靠近敵人,Client 2 留在遠處。
  3. 敵人應該追蹤較近的玩家。
  4. Client 1 被打時,Client 2 不應該一起扣血。
  5. Client 2 靠近後,觀察敵人是否切換到最近玩家。

多人測試可以提早發現「用單一全域 cooldown」或「錯誤引用 LocalPlayer」這類問題。

修正 Prompt

如果敵人完全不動,使用:

【Ch15 修正 1|修復敵人停止移動】
敵人 IslandSentinel_01 在 Play 時完全沒有移動。

預期結果:
- EnemyAIService 應該在 server 執行。
- IslandSentinel_01 應該包含 Humanoid 與 HumanoidRootPart。
- 敵人應該在 SentinelPatrol_01_A/B/C 之間移動。

請檢查:
- Workspace.AdventureIsland.Enemies.IslandSentinel_01
- Workspace.AdventureIsland.PatrolPoints
- ServerScriptService.EnemyAIService

請針對找不到 Humanoid、HumanoidRootPart、巡邏點,以及狀態變化加入 print 訊息。
不要重寫任務系統或 HUD。

如果敵人追蹤玩家但不扣血,使用:

【Ch15 修正 2|敵人會追蹤玩家,但沒有對玩家造成傷害】
敵人會追蹤玩家,但沒有對玩家造成傷害。

預期結果:
- 敵人距離玩家的 HumanoidRootPart 5 studs 以內時,server 應該呼叫 Humanoid:TakeDamage(15)。
- 每位玩家都應該有各自的 1.5 秒傷害 cooldown。

目前行為:
- 敵人可以正常移動。
- 玩家生命值沒有變化。

請只檢查 EnemyAIService。
請檢查距離計算、玩家 Humanoid 的查找方式,以及傷害 cooldown 邏輯。
不要為傷害新增 RemoteEvent。

如果玩家瞬間死亡,使用:

【Ch15 修正 3|敵人在短時間內對玩家造成太多次傷害】
敵人在短時間內對玩家造成太多次傷害。

預期結果:
- 敵人對同一位玩家每 1.5 秒最多只能造成一次傷害。

目前行為:
- 玩家的生命值幾乎在瞬間連續下降。

請在 EnemyAIService 中新增或修正每位玩家各自的傷害 cooldown。
請使用 os.clock() 或其他 server-side 時間檢查方式。
不要把降低 DAMAGE_AMOUNT 當成主要修正方式。

如果 Assistant 一口氣加入太多功能,使用:

【Ch15 修正 4|簡化敵人 AI 原型】
本章只應該包含第一版敵人 AI prototype。

請移除或延後以下功能:
- 掉落物
- XP
- 敵人血條
- 戰鬥 UI
- DataStore
- 完整的 behavior tree

只保留:
- 巡邏
- 追蹤最近的玩家
- server-side 傷害判定
- 傷害 cooldown
- 基本的 server Output 紀錄

工程整理

完成本章後,Explorer 應該接近這樣:

Workspace
└── AdventureIsland
    ├── Enemies
    │   └── IslandSentinel_01
    └── PatrolPoints
        ├── SentinelPatrol_01_A
        ├── SentinelPatrol_01_B
        └── SentinelPatrol_01_C

ServerScriptService
├── CrystalCollectionService
├── GuideHintService
├── QuestService
└── EnemyAIService

整理時檢查這些規則:

  • EnemyAIService 放在 ServerScriptService
  • 敵人模型放在 Workspace.AdventureIsland.Enemies
  • 巡邏點集中在 Workspace.AdventureIsland.PatrolPoints
  • 不要讓每個巡邏點各自有 Script。
  • 不要讓 StarterGuiLocalScript 決定傷害。
  • 不要為傷害建立 client -> server RemoteEvent。

如果你想讓敵人外觀更好,先不要把本章的 AI service 改掉。可以另外用 Assistant 產生模型外觀,再保持同樣名稱與必要結構:

保留 IslandSentinel_01 現有的 Humanoid 與 HumanoidRootPart。
只改善敵人 Model 的視覺零件。
不要修改 EnemyAIService。

這是和 AI 協作時很重要的習慣:功能已經能跑時,視覺修改不要順手重寫邏輯。

本章總結

本章讓 AI Adventure Island 第一次出現風險。玩家不再只是蒐集水晶與回報任務,而是必須觀察敵人路線、保持距離,或快速穿越危險區域。

你建立了:

  • IslandSentinel_01 敵人模型。
  • 三個巡邏點。
  • server-side EnemyAIService
  • Patrol -> Chase -> ReturnToPatrol 狀態流程。
  • 距離偵測與最近玩家選擇。
  • 帶 cooldown 的 server-side damage。

本章最重要的不是敵人有多聰明,而是它的責任邊界是正確的。

Client 顯示世界。
Server 決定敵人行為與傷害。

下一章會在這個風險環境上加入玩家主動能力或道具,讓玩家不只是被敵人追,而是能用技能、工具或冷卻時間來回應威脅。


關於 Wolke

嗨!我是 Wolke,曾任 Google Developer Expert(GDE,2019–2023)LINE API Expert

我熱衷於研究 AI Agent、n8n 自動化工作流與全端開發架構,致力於將 AI 技術轉化為真正能落地的生產力工具。

如果你喜歡這篇文章,歡迎透過以下方式與我交流:

📚 技術著作
《實用的 Gemini API 開發點子書》:帶你運用 Gemini App、Google AI Studio、Gemini CLI 與 Antigravity IDE,打造 AI Agent 與實用產品。

📝 技術部落格
歡迎追蹤我的 Medium,我會持續分享 Agentic Automation、架構設計與實際開發的踩坑心得。

🎤 技術講座與合作
我持續受邀至技術社群及研討會,分享 AI Agent、自動化工作流、DevOps 與全端開發實戰。

我曾於 DevOpsDays Taipei 2026 主講「不再只是寫腳本!讓 AI 代理人成為你的 SRE 最佳夥伴」工作坊。

如果你的企業、社群或學校正在尋找相關主題講者,歡迎私訊聯繫,洽談講座與工作坊合作!

🎮 我的 Roblox 遊戲

🎁 免費贈送 OpenAI 或 Claude AI 額度

為了鼓勵大家實際動手打造自己的 Roblox 體驗,我每個月會開放:

  • 10 個名額
  • 每人 50 點 AI 額度
  • 名額送完為止

參加方式:

  1. 訂閱本系列文章。
  2. 分享任一篇系列文章。
  3. 私訊分享截圖及你的 AI 帳號 Email。

確認完成後,我會邀請你加入並設定 50 點額度。名額有限,歡迎把握機會!


上一篇
第 14 章:任務系統
下一篇
第 16 章:道具、能力與冷卻時間
系列文
Roblox Studio AI 協作開發大全23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言